Skip to content

fix(fetch): remove abort listener when request settles#5318

Open
ATOM00blue wants to merge 1 commit into
nodejs:mainfrom
ATOM00blue:fix/fetch-abort-listener-leak
Open

fix(fetch): remove abort listener when request settles#5318
ATOM00blue wants to merge 1 commit into
nodejs:mainfrom
ATOM00blue:fix/fetch-abort-listener-leak

Conversation

@ATOM00blue
Copy link
Copy Markdown

Problem

fetch() attaches an abort listener to the passed AbortSignal on every call — in the Request constructor and in the fetch algorithm — but only removes them via a FinalizationRegistry (on GC). Reusing one signal across many requests accumulates listeners and Node emits MaxListenersExceededWarning.

Fix

Capture the listener-removal callbacks and invoke them deterministically once the fetch settles, covering the end-of-body, network-error and abort paths. The Request constructor exposes its cleanup through an internal static accessor following the existing pattern in request.js, so no new public symbol is introduced.

Test

Added test/fetch/issue-5285.js: issues 100 fetch calls sharing one AbortController signal and asserts no abort listeners remain and no MaxListenersExceededWarning is emitted. Fails on main, passes here. Full test/fetch suite (471 tests) and node-fetch suite pass.

Closes #5285

fetch() registers an `abort` listener on the passed AbortSignal (in both
the Request constructor and the fetch algorithm) but only removed it via
the FinalizationRegistry, i.e. on garbage collection. Reusing a single
signal across many requests therefore accumulated listeners and Node.js
emitted a MaxListenersExceededWarning.

Capture the listener-removal callbacks and invoke them deterministically
once the fetch settles (end-of-body, network error and abort paths) so
that no listeners are leaked when a signal is reused.

Closes nodejs#5285
Copilot AI review requested due to automatic review settings May 21, 2026 02:35
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds deterministic cleanup for AbortSignal listeners used by fetch()/Request to prevent listener leaks and MaxListenersExceededWarning when reusing a single signal across many requests.

Changes:

  • Add request-level abort-listener cleanup plumbing in Request and expose it for fetch() to call.
  • Ensure fetch() removes abort listeners on error/abort/end-of-body settlement paths.
  • Add a regression test covering listener leakage when reusing one AbortSignal across many fetch() calls.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
test/fetch/issue-5285.js Adds regression test asserting no leaked abort listeners / warnings when reusing a signal.
lib/web/fetch/request.js Tracks and exposes deterministic removal of the listener that ties request signal to an external signal.
lib/web/fetch/index.js Calls cleanup hooks so request/fetch abort listeners are removed when the fetch settles.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/fetch/issue-5285.js
Comment thread test/fetch/issue-5285.js
Comment thread lib/web/fetch/request.js
Comment thread lib/web/fetch/index.js
Comment on lines 211 to 229
@@ -228,6 +228,15 @@ function fetch (input, init = undefined) {
}
)
@metcoder95 metcoder95 requested review from KhafraDev and tsctx May 21, 2026 09:03
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.95918% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.23%. Comparing base (74e343b) to head (7d99406).

Files with missing lines Patch % Lines
lib/web/fetch/index.js 94.11% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #5318   +/-   ##
=======================================
  Coverage   93.22%   93.23%           
=======================================
  Files         110      110           
  Lines       36599    36642   +43     
=======================================
+ Hits        34121    34162   +41     
- Misses       2478     2480    +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread lib/web/fetch/index.js
// deserializedError.

abortFetch(p, request, responseObject, controller.serializedAbortReason, controller.controller)
cleanupAbortListeners()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this function call to abortFetch.

Comment thread test/fetch/issue-5285.js

// Allow the trailing end-of-body cleanup of the final request, which is
// scheduled in a microtask, to run before asserting.
await new Promise((resolve) => setTimeout(resolve, 100))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  const { setImmediate } = require('node:timers/promises')
Suggested change
await new Promise((resolve) => setTimeout(resolve, 100))
await setImmediate()

Comment thread test/fetch/issue-5285.js
// otherwise a MaxListenersExceededWarning is emitted and the listeners leak.
for (let i = 0; i < 100; i++) {
const res = await fetch(url, { signal })
await res.text()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await res.text()
await res.arrayBuffer()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a personal preference. It avoids string allocation, which makes the tests faster.

Comment thread lib/web/fetch/index.js
Copy link
Copy Markdown
Member

@mcollina mcollina left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised this does not cause regressions, but in hindsight it should be ok

lgtm but we need to wait @KhafraDev opinion too.

@KhafraDev
Copy link
Copy Markdown
Member

I'm surprised this does not cause regressions, but in hindsight it should be ok

I agree. A possible issue is that abort from onRequestStart won't be called anymore if an AbortController is aborted after a request, but I'm not sure if it has any impact. We have decent test coverage for signals, GC, and cloning requests, and no WPTs have regressed either.

If this has no consequences, I think this should be upstreamed to the fetch spec. At the bare minimum, a note stating "the abort listener added can be removed here" could be added to the call sites.

@tsctx
Copy link
Copy Markdown
Member

tsctx commented Jun 2, 2026

At first, I didn’t think this would work, but after thinking it through more carefully, it actually seems to work quite well.

That said, since this behavior is outside the specification, we should proceed with caution. I found one edge case that is worth considering. In this scenario, determining when it is safe to detach the signal is very similar to how garbage collection works.

Specifically, the link can only be removed when:

  • the fetch fails or is aborted; or
  • all responses, including cloned responses, have been fully consumed, or have lost all references and been garbage-collected. This should be equivalent to the fetch controller associated with the fetch being fully disposed of.

As far as I can tell, only browsers handle this edge case correctly today; it has not yet been implemented in server-side fetch. This is not an immediate problem since it is currently unimplemented, but it will likely become an issue if we decide to implement it later.

const c = new AbortController();

const r = await fetch(
  new URL("/", globalThis.location?.href ?? "https://example.com/"),
  { signal: c.signal },
);

const r2 = r.clone(); // Clone it.

await r.arrayBuffer(); // Consume the entire body first to confirm the request has ended.

c.abort(); // Abort.

console.log((await r2.text()).slice(0, 100)); // This should throw an error because they are shared.

If this is a known limitation and we are comfortable with it for now, I’m fine with moving forward. However, it is something we will need to take into account if and when we implement this in the future.

@KhafraDev
Copy link
Copy Markdown
Member

KhafraDev commented Jun 2, 2026

If this is a known limitation and we are comfortable with it for now, I’m fine with moving forward. However, it is something we will need to take into account if and when we implement this in the future.

I can't find anything in the spec that says this case should throw. Browsers seem to be mishandling it.

@KhafraDev KhafraDev dismissed their stale review June 2, 2026 16:31

outdated

@tsctx
Copy link
Copy Markdown
Member

tsctx commented Jun 4, 2026

I can't find anything in the spec that says this case should throw. Browsers seem to be mishandling it.

It seems like the browser is correct.
It makes sense if you think about it this way: the current implementation relies on a shared underlying fetch's body stream, which correctly aborts and works as long as the entire body isn't consumed first. This same logic can be applied to cases where the entire body is fully consumed.

const c = new AbortController();

const r = await fetch(
  new URL("/", globalThis.location?.href ?? "https://example.com/"),
  { signal: c.signal },
);

const r2 = r.clone(); // Clone it.

c.abort(); // Abort.

console.log((await r2.text()).slice(0, 100)); // This throw an error because they are shared.

@KhafraDev
Copy link
Copy Markdown
Member

KhafraDev commented Jun 4, 2026

It seems like the browser is correct.

Based on what?

Once the body is consumed, the stream is no longer readable and therefore is never canceled/errored, which propagates to the teed stream(s) (seemingly, I could be wrong as I'm not an expert in webstreams). In the 2nd example, since the first response's body's stream is still readable when the signal is aborted, it errors.

Spec:
#abort-fetch:

  1. If request’s body is non-null and is readable, then cancel request’s body with error.
  2. If response’s body is non-null and is readable, then error response’s body with error.

#concept-http-network-fetch:

18.2.1.2. If stream is readable, then...
18.2.2. Otherwise, if stream is readable,...

@tsctx
Copy link
Copy Markdown
Member

tsctx commented Jun 4, 2026

Good question. I agree that this is not Response.clone() inheriting or copying an AbortSignal; Response has no signal property.

The point is the body stream. Response.clone() clones the body by teeing its stream, so the cloned body is a branch derived from the same body stream, not an independent fetch. The Fetch Standard defines body cloning in terms of teeing the body stream, and Streams defines tee branches as observing errors from the original stream.

Your reading of abort fetch is reasonable if only the original r.body branch is considered the response body. After await r.text(), that branch is closed, so the “if body is readable” condition would not error it. But I think the subtle point is what counts as the relevant fetch-backed body after cloning.

Browsers appear to preserve the fetch-backed body relationship across the tee branches created by Response.clone(). So the unread cloned branch is not treated as a detached stream. Aborting the request can still error that branch, not because the Response inherited a signal, but because the cloned body remains part of the fetch-created body stream machinery.

So the browser behavior is better described as preserving the fetch-abort/error linkage across cloned body branches, not as Response-level signal inheritance.

@KhafraDev
Copy link
Copy Markdown
Member

KhafraDev commented Jun 4, 2026

Browsers are actually handling this correctly, but for different reasons - we aren't teeing the streams 'properly', and I'm not sure we are able to without access to ReadableStreamTee.

#rs-tee (webstreams) (this is how ReadableStream.prototype.tee is implemented):

Return ? ReadableStreamTee(this, false).

#concept-body-clone (fetch):

Let « out1, out2 » be the result of teeing body’s stream.

"teeing" links to #readablestream-tee (webstreams) (how .clone() tees the streams):

return ? ReadableStreamTee(stream, true).

... which has the note:

Because we pass true as the second argument to ReadableStreamTee, the second branch returned will have its chunks cloned ... from those of the first branch. This prevents consumption of one of the branches from interfering with the other.

So there is a bug in undici, but this PR is not responsible for it, and will not cause any issues in implementing it if we ever have access to ReadableStreamTee from node core.

@tsctx
Copy link
Copy Markdown
Member

tsctx commented Jun 4, 2026

Thanks for clarifying — that makes sense. I agree this should be framed as a Fetch body-clone issue, not as Response.clone() inheriting an AbortSignal.

Fetch body cloning uses the Streams “teeing” operation corresponding to ReadableStreamTee(stream, true), while ReadableStream.prototype.tee() uses ReadableStreamTee(this, false). So plain stream.tee() cannot fully model Fetch’s body-clone semantics.

Given that, browsers are handling this correctly. This PR does not introduce the bug; it exposes an existing limitation that should be tracked separately until an equivalent of ReadableStreamTee(stream, true) is available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MaxListenersExceededWarning when using signal

6 participants